Using SignalR for real-time IoT device coordination

If you are a software developer who has worked with .NET, you may have heard of SignalR, a library that allows applications to communicate with each other in real-time over the network. However, not everyone is aware of aware of all the interesting things SignalR is capable of.

Many developers I spoke to are only aware of SignalR as a technology that allows the building of real-time chat applications because this is what most online examples focus on. So, let’s address this situation and talk about another interesting usage of SignalR – the coordination of Internet of Things (IoT) devices by the server.

Real-life Project Analogy

What we will talk about today isn’t just theoretical. It’s based on a real-life solution that I built with my team while working at a startup that specialized in modernizing information infrastructure for passenger trains and railway stations. The solution I will present follows the same principle as the real-life solution, but it’s highly simplified.

The project involved the replacement of severely outdated chunky computers at railway stations with clusters of small single-board IoT devices. You can think of them as equivalent to Raspberry Pi, only much more robust. Each of these devices was assigned to an individual railway platform. The devices served many purposes and one of those was to make public announcements.

The devices needed to be coordinated and act as clusters rather than individual devices. For example, if one device is already announcing an upcoming train, no other device in its vicinity should play its own announcement until the first device has finished. This would solve a very common problem where you, as a passenger, are trying to figure out what stations the next train is stopping. But you can’t make it out because another set of speakers is talking over it while telling you that smoking is not permitted at the station!

So, the solution was as follows:

  1. The server, which was constantly receiving train movement information via the live feed, would instruct a specific device at a specific platform of a specific station to play an announcement at a specific time.
  2. The device would be aware of other devices in its vicinity and would first check if none of them are playing announcements.
    1. If another device is already playing an announcement, the new announcement will be queued and played after the original announcement is finished.
    2. If there is no existing announcement being played, the new announcement is played right away.

Both the server components and the software on the devices were written in .NET, which was called .NET Core back then. The technology was chosen because it allows to write software for any operation system and any CPU architecture, which made it perfect for deployment on IoT devices, even if it was developed on a standard Windows PC. The setup can be illustrated as follow:

Of course, it was much more complicated than this. For example, there were also different priority levels for different announcement types. There was also text-to-speech service involvement. However, the core operational principles outlined above are sufficient to explain how SignalR can be used for IoT device coordination.

Also, the same principles aren’t only applicable to audio playback. They are equally applicable to absolutely any problems that can be solved via using clusters of IoT devices and coordinating tasks between them.

So, let’s now go through code examples that illustrate these principles. The complete solution can be found in this GitHub repo.

Setting Up a SignalR Hub

The main server-side component of SignalR is known as a SignalR hub. Our hub looks like the following:

using Microsoft.AspNetCore.SignalR;
using IotHubHost.Data;

namespace IotHubHost.Hubs;

public class DevicesHub : Hub
{
    public async Task ReceiveDeviceConnected(string deviceId, string areaName, string locationNumber)
    {
        UserMappings.AddDeviceConnected(deviceId, Context.ConnectionId);           
        LocationMappings.MapDeviceToLocation(locationNumber, Context.ConnectionId);
        await Groups.AddToGroupAsync(Context.ConnectionId, areaName);
    }

    public async Task BroadcastWorkStatus(string areaName, bool working)
    {
        await Clients.Groups(areaName).SendAsync("ReceiveWorkStatus", working);
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        UserMappings.RemoveDeviceConnected(Context.ConnectionId);
        await base.OnDisconnectedAsync(exception);
    }
}

Let’s go through the methods one by one:

  • The ReceiveDeviceConnected() method is invoked by an IoT device when the device first establishes a connection. The device sends information about itself, such as device id (which can be the serial number), area name (for example, the name of the railway station), and the location number (such as the platform number). This information is then registered.
  • The BroadcastWorkStatus() method is invoked by a device when it either starts or finishes a task (such as audio playback). This information is then broadcast by all devices in its cluster, so they know either that one of the devices in the cluster is currently busy or that it’s no longer busy.
  • The OnDisconnectedAsync() override is used when a device disconnects from the network, so all information about it can be cleared.

Let’s now see how connection information is stored on the server so that it recognizes how the IoT devices are clustered together.

Storing Device Connection Information

Of course, in a real-life solution, we would use a proper database (or Redis cache) to store the device connection information. However, to make the demonstration simple, we are using in-memory mapping provided by the following code:

namespace IotHubHost.Data;

public static class LocationMappings
{
    private static readonly Dictionary<string, string> locationMappings = new Dictionary<string, string>();

    public static void MapDeviceToLocation(string locationNumber, string connectionId)
    {
        locationMappings[locationNumber] = connectionId;
    }

    public static string GetConnectionId(string locationNumber)
    {
        if (!locationMappings.ContainsKey(locationNumber))
            return string.Empty;

        return locationMappings[locationNumber];
    }
}

SignalR identifies individual connections by arbitrarily generated connection ids, which are long strings of letters and numbers. Those things are not meaningful to us. We need to be able to map individual connections by unchangeable well-known identifiers, such as device serial numbers. The above class creates the mapping between such identifiers and SignalR connection ids.

We can also create a mapping between location identifiers and location descriptions, as the following class demonstrates:

namespace IotHubHost.Data;

internal static class LocationsAreaMapper
{
    private static readonly Dictionary<string, string> locationsInAreas = new Dictionary<string, string>
    {
        {"1", "North Wing"},
        {"2", "North Wing"},
        {"3", "South Wing"},
        {"4", "South Wing"}
    };

    public static string GetLocationName(string locationNumber)
    {
        if (locationsInAreas.ContainsKey(locationNumber))
            return locationsInAreas[locationNumber];

        return string.Empty;
    }
}

Another thing we do on the server is schedule the work for specific devices at specific locations. But before we come back to it, let’s see how the app is configured on the device itself.

Setting Up The App on IoT Devices

IoT devices are typically accessed via the command line; therefore if we are to write an app for such a device by using .NET, we wouldn’t need anything more complicated than a basic console application.

In our example, the whole logic fits inside the Program.cs file of the console app. Here is what the code is with the comments added to explain what each part of the code is doing:

using Microsoft.AspNetCore.SignalR.Client;

// If set to true, no work will be done.
bool holdOffWork = false;

// In case there is a network outage and no work completed signal was received, the above flag will reset itself to false after this timeout has elapsed.
int timeoutSeconds = 60;

// Prompting the user to enter the device identifier. Can be replaced with a CLI parameter.
Console.WriteLine("Please provide device identifier.");
string? identifier = Console.ReadLine();

// Prompting the user to enter the name of the area where the device is located. Can be replaced with a CLI parameter.
Console.WriteLine("Please provide the area name for the device.");
string? areaName = Console.ReadLine();

// Prompting the user to enter the location of the device. Can be replaced with a CLI parameter.
Console.WriteLine("Please provide the location identifier for the device to be positioned at.");
string? gateNumber = Console.ReadLine();

// Configuring the SignalR connection infomation. This doesn't start the connection yet.
HubConnection connection = new HubConnectionBuilder()
    .WithUrl("http://localhost:57100/devicesHub")
    .Build();

// Mapping a ReceiveWork SignalR event with the DoWork() method defined below.
connection.On("ReceiveWork", DoWork);

// Setting the vaue of the holdOffWork variable to either true or false depending on what signal is received from the ReceiveWorkStatus event.
connection.On<bool>("ReceiveWorkStatus", (working) => holdOffWork = working);

// Establishing a persistent SignalR connection.
await connection.StartAsync();

// Invoking the ReceiveDeviceConnected() method on the SignalR hub with the parameters.
await connection.InvokeAsync("ReceiveDeviceConnected", identifier, areaName, gateNumber);

// Triggered when the device is instructed to do work.
async Task DoWork()
{
    var receiveTime = DateTimeOffset.Now;

    // Holding off work util either a signal received that the work by another device has been completed or until the timeout elapses.
    while (holdOffWork)
    {
        Console.WriteLine("Other device is doing work. Waiting...");
        if (DateTimeOffset.Now.AddSeconds(-timeoutSeconds) > receiveTime)
            holdOffWork = false;

        await Task.Delay(1000);
    }

    // Sending the notification of starting work to the SignalR hub.
    await connection.InvokeAsync("BroadcastWorkStatus", areaName, true);

    // Simulating work by doing a delay.
    Console.WriteLine("Work Started");
    await Task.Delay(60000);
    Console.WriteLine("Work Finished");
 
    // Sending the work finished status to the SignalR hub.
    await connection.InvokeAsync("BroadcastWorkStatus", areaName, false);
}

In this example, we are merely simulating work by writing into a console and establishing a 60-second delay. But this can be replaced with real work, such as audio playback, or software update.

Scheduling Work Instructions

Now, we can go back to the server and see how work can be scheduled. In our example, since we are running everything in a single ASP.NET Core application, we run this scheduler in a hosted background service, which merely sends instructions to specific devices via the SignalR hub reference:

using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
using IotHubHost.Data;
using Microsoft.AspNetCore.SignalR;
using IotHubHost.Data;
using IotHubHost.Hubs;

namespace IotHubHost;

internal class EventScheduler : IHostedService, IDisposable
{
    private readonly IHubContext<DevicesHub> _hubContext;

    private readonly string receiveWorkMethodName = "ReceiveWork";

    public EventScheduler(
        IHubContext<DevicesHub> hubContext)
    {
        _hubContext = hubContext;
    }

    public void Dispose()
    {
        
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        var locationsList = new List<string> { "1", "2", "3", "4" };

        while (!cancellationToken.IsCancellationRequested)
        {
            foreach (var location in locationsList)
            {
                var connectionId = LocationMappings.GetConnectionId(location);

                if (!string.IsNullOrWhiteSpace(connectionId))
                    await _hubContext.Clients.Client(connectionId).SendAsync(receiveWorkMethodName);
                else
                    await _hubContext.Clients.Groups(LocationsAreaMapper.GetLocationName(location)).SendAsync(receiveWorkMethodName);

                await Task.Delay(30000);
            }
        }
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }
}

Please note how it uses the mappers we previously discussed and uses the real SignalR connection ids to send instructions to individual SignalR clients.

Where to go From Here

Today, we only covered a very basic example of coordinating IoT devices via SignalR. If you want to explore the subject further, you can make it more interesting. For example, instead of simulating the work by having a 60-second delay, why not make it play audio?

To enable this, you can use the NetCoreAudio NuGet package, which allows you to play audio on any operating system. In fact, I’ve built this NuGet package based on the code of the original project I described above.

Also, if you want to explore the subject even further, I have this interactive liveProject on the Manning website. This liveProject will guide you on how to build a full distributed solution. You will apply the same principles to build a PA system for an airport.

That’s it for today. Next time, we will continue exploring the subject of IoT architecture. I will talk about how IoT clusters can be set up to deal with low bandwidth and hardware failures.


P.S. If you want me to help you improve your software development skills, you can check out my courses and my books. You can also book me for one-on-one mentorship.